feat(kms): authenticate RA-TLS clients by attestation instead of by issuer - #1106
Merged
Conversation
kvinwang
force-pushed
the
feat/ratls-self-signed-client-certs
branch
from
August 23, 2026 15:13
5b9521b to
8f51f71
Compare
kvinwang
force-pushed
the
feat/ratls-self-signed-client-certs
branch
from
August 23, 2026 15:31
e9944b2 to
4dafddf
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
feat(kms): authenticate RA-TLS clients by attestation instead of by issuer
Server-side half of removing the temp-CA workaround. The client-side half — guests
minting self-issued certificates — is split into a separate PR, because it narrows
which KMS versions a guest image can talk to and deserves its own decision.
Problem
Rocket configures mutual TLS through rustls'
WebPkiClientVerifier, which pins a CA(
core/lib/src/tls/config.rs:453-467).mandatory = falsecompiles to.allow_unauthenticated(), whose semantics are "you may omit a certificate, but if yousend one it must chain to the pinned CA". There is no "accept any certificate, let the
application decide" mode.
An RA-TLS certificate is self-issued and carries its identity in a TEE quote whose
report_databinds the certificate's own SPKI. There is nothing for it to chain to. Sodstack invented something:
GetTempCaCerthands every caller the temp CA certificateand its private key (
kms/src/main_service.rs:497-512), the caller mints itself acertificate from it, and rustls is satisfied.
That CA establishes nothing. The endpoint is unauthenticated, so the key is public by
design — anyone can mint a chain-valid client certificate. The check that has always
carried the meaning runs afterwards, in
ra-rpc/src/rocket_helper.rs:529-551:and the KMS is explicit about this —
RpcHandler::construct(main_service.rs:567-571)drops
remote_app_id/remote_app_infoand keeps only the verified attestation.The consequence is that the TLS layer admits everyone, so every authorization check has
to be remembered by hand in each handler. A handler that forgets
ensure_attested()issilently open. This is the "attestation is the trust boundary — not TLS, not the network"
principle from
.agent/CODING_TASTE.md, enforced one layer too late.Reported as #561 and
#619; previously closed as documented.
Fix
Replace the chain check with the check that means something. Rocket master already
exposes
tls::Resolver(core/lib/src/tls/resolver.rs), which returns an arbitraryArc<rustls::ServerConfig>per ClientHello — so no fork is needed and no rocket API isbeing abused.
ra-rpc::ratls_client_verifieradds:RaTlsClientVerifier, arustls::ClientCertVerifierthat advertises no CAs, ignoresthe issuer, and requires the certificate to carry a decodable RA-TLS attestation;
RaTlsClientAuth, atls::Resolverserving oneServerConfigwired to it.The KMS attaches it with one line. Everything downstream is untouched: rocket populates
peer_certsstraight from rustls (listener/connection.rs:88-92, gated only on themtlscargo feature, not on[tls.mutual]), androcket::mtls::Certificateonly parses(
mtls/certificate.rs:114-133).rocket_helper.rsand the gateway needed no changes.The expensive half stays out of the handshake.
verify_client_certis synchronouswhile quote verification needs I/O (collateral fetch, auth API). Doing it there would
hand unauthenticated peers a lever to drive that I/O, so the verifier does only local
work and
rocket_helper.rs+ the service handlers keep doing the rest exactly as before.No client changes required
Guests and KMS↔KMS onboarding still fetch the temp CA and mint their client certificates
from it. Those certificates are now accepted for the attestation they carry rather than
for their issuer, so every existing client keeps working unchanged, in both directions:
old guests against a new KMS, and this KMS against everything that talks to it today.
What changes:
requiring an attested one — a handler that forgets
ensure_attested()is no longersilently open;
migrate callers off
GetTempCaCertlater.GetTempCaCertand the temp CA are untouched and still needed. Both remaining callersare annotated in
kms_rpc.protoand on the handler, so the next reader does not have tore-derive why a CA private key is being served.
[rpc.tls.mutual]is no longer the trust anchor and is dropped fromkms.tomland theKMS config templates (
tools/dev-stack.sh,kms/dstack-app/compose-*.yaml,crates/dstack-cli-core/src/config.rs,test-suites/, the KMS tutorials). Leaving thesection in an existing deployment's config is inert, not an error. The gateway's
[tls.mutual]is deliberately untouched: it pins the KMS root CA, which is a real trustanchor, since the app identity in those certificates is asserted by the KMS at issuance.
Verification
Two KMS binaries — unmodified
origin/nextand patched — same dev config otherwise,probed with
curl. Client certificates carry a syntactically valid but deliberatelynon-binding attestation, so a reply of
invalid quote: report data mismatchis the applayer proving it still verifies quotes after the handshake succeeded.
origin/next, pins tmp-ca)[rpc.tls.mutual])invalid quote: report data mismatchinvalid quote: report data mismatchinvalid quote: report data mismatchTLS alert, handshake failure (552))App not allowed: No attestation providedGetMetaca_certGetTempCaCertRow 1 is the compatibility guarantee: what every client sends today is accepted by both.
Row 2 is the new capability. Row 3 shows the verifier is enforcing, not just permitting.
Rows 4–6 show the unauthenticated surface and the app-layer gate are unchanged.
The patched KMS starts with no
[rpc.tls.mutual]section at all and logsRA-TLS client certificate verification enabled. The same matrix was re-run against aKMS booted from a config generated by the cleaned
tools/dev-stack.shtemplate, toconfirm the config edits produce a working KMS and not just a parsing one.
KMS↔KMS onboarding was checked by reading rather than executed (it needs two KMS CVMs):
gen_ra_cert(onboard_service.rs:747-767) produces a CA-signed certificate carrying anattestation, which is exactly row 1; the onboard listener itself never merges
rpcintoits figment, so it has no
tlssection, serves plain HTTP, and the fairing is notattached to it. Nothing in
kms/src/readsmutual.Automated coverage:
ra-rpc/src/ratls_client_verifier.rs— 4 unit tests: CA-signed accepted, self-signedaccepted, no-attestation rejected, expired rejected (the validity window webpki used to
enforce is now enforced by the verifier).
ra-rpc/tests/ratls_client_auth.rs— end-to-end through a real Rocket server with thefairing attached, asserting the same four outcomes at the TLS layer and that
rocket::mtls::Certificatestill receives the certificate.cargo fmt --check,cargo clippy -- -D warnings -D clippy::expect_used -D clippy::unwrap_used --allow unused_variables, and the touched crates' test suites areclean.
Follow-ups
GetTempCaCert.Split out because it narrows KMS compatibility for a guest image.
GetTempCaCertand the temp CAcan be deleted.
register_cvmprefers the certificate extension over the verifiedattestation (
gateway/src/main_service.rs:1529-1538), which is sound only because itpins a real CA. Making that dependency explicit is worth its own change.